Demo project
What are we going to do?
Here, we will create a client application and a server application, both using Jopi Remote Call to communicate.
Our server will host a service exposing four functions: hello
, requireReaderRole
, uploadFile
, printMyVersion
.
- The
hello
function will be very simple: it takes a name as an argument and returnshello ${name}
. - The
requireReaderRole
function will be secured. Only authenticated users with thereader
role will be able to call it. - The
uploadFile
function will allow sending a file to the server. You will see how simple this is! - The
returnInfoAboutMe
function will display information about the client calling the server.
On its side, the client application will host a function named myVersion
that returns information about its version.
What will we see?
We will cover the following topics:
- How to create a server application.
- How to create a client application.
- How to create and expose a service with functions.
- How to secure access to a function in this service.
- How to send a file.
- How to call a function exposed by the browser.
These are the main elements to know—once you understand these, you already know almost everything!
Prerequisites
First, you will need to install Bun.js
.
Jopi Remote Call works with Node.js and Bun.js. Here we choose Bun.js only for the sake of simplicity in this tutorial, as Bun.js does not require transpiling Typescript to Javascript.
If you already have Node.js installed, you can run npm install -g bun
to install bun.js.
Otherwise:
- For Linux/OS users:
curl -fsSL https://bun.sh/install | bash
- For Windows users:
powershell -c "irm bun.sh/install.ps1 | iex"
Official bun website: https://bun.sh
Creating the test project
Once Bun.js is installed, we need to create a folder with the following three files.
- package.json
- serverApp.ts
- clientApp.ts
{
"scripts": {
"server": "bun --watch serverApp.ts",
"client": "bun --watch clientApp.ts"
},
"dependencies": {
"jopi-remote-call": "*",
"@jopi-remote-call/bunjs": "*"
}
}
// Automatically install bunjs dependencies.
import "@jopi-remote-call/bunjs";
import * as Broker from "jopi-remote-call";
// >>> Start our server on port 3000.
Broker.startBrokerServer({port: 3000, pathname: "/65a9e437-7471-4942-91fd-f4fbb274f4c8"});
console.log("Server is started");
// >>> Installation of a function named Hello.
Broker.registerFunction("hello", async (name: string) => "Hello " + name);
import * as Broker from "jopi-remote-call";
// >>>Create a connection to our server.
const socket = await Broker.WebSocketClient.newConnection("localhost:3000/65a9e437-7471-4942-91fd-f4fbb274f4c8");
// >>> Wait until the connection is ok.
console.log("Waiting connection ready");
await socket.waitReady();
// >>> Test: calling function "hello".
console.log("\nTest: calling 'hello'");
const res = await socket.callFunction("hello", "my-name");
console.log("--> Result:", res);
Focus on the interesting parts
On the server side, initializing the server is very simple.
Broker.startBrokerServer({port: 3000, pathname: "/65a9e437-7471-4942-91fd-f4fbb274f4c8"});
The URL is special. The part 65a9e437-7471-4942-91fd-f4fbb274f4c8
helps protect the server from attackers by using a URL that is hard for a network-scanning bot to guess. For us, the server URL has no particular meaning, unlike classic web services, so we can protect our URLs.
Next, we have the following code, which exposes a function named hello
:
Broker.registerFunction("hello", async function (name: string) {
const message = "Hello " + name;
console.log(message);
return message;
});
On the client side, we call our function like this:
await socket.callFunction("hello", "my-name");
When we discuss services, you will see that calling a service is even simpler and more natural!
Testing our application
Here we have a server and a client application. To test our application, we need to start both components.
Starting the server: `bun --watch run server`
Starting the client: `bun --watch run client`
Once started, the client should display:
Waiting connection ready
Calling hello
Response: Hello my-name
If the client is stuck on the message Waiting connection ready
then:
- Either your server is not started.
- Or port 3000 is already in use by another application.
Once the server and client are started, bun.js will automatically watch for changes in our two files.
When they are modified, it will automatically restart the server or client.
Creating a service
Exposing a function is very simple, but functions do not support security mechanisms to check if the client is authenticated (login/password) and has the necessary access rights (roles).
That’s why we will create a service
.
A service is a group of functions. In some ways, they are equivalent to a class (class
in Typescript).
Creating the @ServiceA service
First, we will create the service.
// >>> Install a service named @ServiceA.
// We create our service.
// > A service name always starts with the symbol '@'.
//
const serviceA = new Broker.BrokerService("@ServiceA");
// Once created, we make it reachable by our clients.
serviceA.makePublic();
Now we will add a function named helloFromService
. It's a very simple function, similar to our hello
function.
// >>> Add function helloFromService to @ServiceA.
serviceA.addFunction("helloFromService", async (name: string) => "Hello " + name);
Calling @ServiceA:helloFromService
On the client side, we will modify the code to call this function.
// >>> Test: calling our function @ServiceA:helloFromService.
// What we do here is like const serviceA = new ServiceA();
const serviceA = await socket.getServiceAccessor("@ServiceA");
// Now we can call our function helloFromService.
console.log("\nTest: calling @ServiceA:helloFromService");
console.log("--> Result:", await serviceA.helloFromService("my-name"));
Bun.js automatically detects changes, so the console output updates after you modify the code. After saving, you should now see this in the console:
Test: calling @ServiceA:helloFromService
--> Result: helloFromService is called with: my-name
Typing the value of serviceA
There is a problem with the previous sample: the variable serviceA
isn't typed.
The code runs fine, but your IDE will show a warning. That's why you should update it like this:
interface ServiceA {
helloFromService(name: string): Promise<string>;
// Here: add other functions we will add to @ServiceA.
}
// What we do here is like const serviceA = new ServiceA();
const serviceA = await socket.getServiceAccessor<ServiceA>("@ServiceA");
Adding a secured function
Server side
We created a service and added a first function. Now we will add a second function named requireReaderRole
. This function will have a security constraint: the client must be authenticated and have the reader
role in their permissions.
// >>> Add a function requireReaderRole to @ServiceA.
// Here we add our function requireReaderRole.
const requireReaderRole = serviceA.addFunction("requireReaderRole", async () => "Hello from requireReaderRole");
// Now we add security to our function requireReaderRole.
requireReaderRole.addRequiredRole("reader");
The way to declare a function does not change. Here, we declared our requireReaderRole
function the same way as our helloFromService
function. Adding a security constraint is done afterward.
Adding JWT support
On the client side, we call our function the same way as requireReaderRole
. However, there will be a problem if we try to do so: a 'BrokerError_NotAuthorized' exception will be thrown. The reason is simply that our client application is not authenticated.
At no point have we used a login/password system or equivalent. That's what we will do here.
First, we will add a mechanism called JWT Tokens
. This mechanism allows us to remember that the client is authenticated (their login/password are verified). Here, everything is already included in the framework, so we just need to activate this mechanism.
// >>> Add support for JWT Tokens
Broker.initJwtTokenManager({secret: "5682a3dd-46c6-4a2d-8420-507e18e5d429"});
Now we need to add something important: a function that will indicate if the login/password is valid, and in return will send back information about our user, including their permissions/roles.
// >>> Add login/password verification.
Broker.setSignInVerificator(async function (userLoginInfos: UserLoginInfo): Promise<UserInfos|null> {
// Some checks you should always add.
//
if (!userLoginInfos) return null;
if (!userLoginInfos.login) return null;
// We only authorize this user.
if (userLoginInfos.login!=="im-the-boss") return null;
if (userLoginInfos.password!=="the-boss-password") return null;
// If user is accepted, then return his infos.
// Otherwise, return null.
//
return {
login: userLoginInfos.login,
mail: userLoginInfos.login + "@my-company.com",
roles: ["user", "reader"]
};
});
// We can put what we want inside UserLoginInfo.
//
interface UserLoginInfo {
login: string;
password: string;
}
// It's the same for what is returned.
//
interface UserInfos extends Broker.WithRoles {
login: string;
mail: string;
}
Here you are free to do what you want and choose the content of UserLoginInfo, which allows you to use your own authentication protocol. For example, you can use a hash for the password.
Client side
On the client application side, we need to start by authenticating by providing the login/password.
// >>> Sending our login/password to authenticate.
const jwtToken = await socket.jwtAuthenticate({login: "im-the-boss", password: "the-boss-password"});
if (!jwtToken) console.error("User/password refused !");
// Print what is inside my JWT token.
console.log("--> Content of my token:", Broker.getAppUserInfos()!);
After this change, the application will display the following, which means we are authenticated:
Test: authenticate
--> Content of my token: {
login: "im-the-boss",
mail: "im-the-boss@my-company.com",
roles: [ "user", "reader" ],
iat: 1752678842,
exp: 1753283642,
}
We can now call our function @ServiceA:requireReaderRole. Here I added a try/catch
to show you how to distinguish a security error from another error. As you can see, the way to call requireReaderRole
is the same as for other functions; there is no difference on the client side.
// >>> Test: calling our function @ServiceA:requireReaderRole.
try {
await serviceA.requireReaderRole();
}
catch(e) {
if (e instanceof Broker.BrokerError_NotAuthorized) console.log("Access not authorized");
else console.log("Other error:", e);
}
Calling the client from the server
Here, we will see how the server can call our client application. Just as we can call functions exposed by the server, we can also expose functions that the server can call. The way to do this is exactly the same: registerFunction
to expose a function, or create a service and call myservice.makePublic()
.
The only difference between a client and a server is how we use them. A client application tends to communicate only with the server, while a server communicates with several clients. Beyond that, everything works the same way, with the same capabilities.
Creating the function returnInfoAboutMe
Here we will create a function named returnInfoAboutMe
, which will be exposed by the server. Its role is to return all the information the server knows about the client calling it.
// >>> Add the function returnInfoAboutMe
Broker.registerFunction("returnInfoAboutMe", async () => {
// >>> Get information about the client app calling us.
// The clientId allows to identify a client app.
//
// Each server and client has a unique id which is generated when the application starts.
// --> It's not the version of the app, but a random value which is unique.
//
const clientId = Broker.getCallingClientId();
// The JWT token of the client. Is set when the client is authenticated.
const clientJwtToken = Broker.getCallingClientJwtToken();
// Information about the user.
// It's decoded from the client JWT token.
//
const userInfo = Broker.getCallingAuthInfo();
// >>> Now we want to know their app version
// Get the socket allowing us to call our client.
// The broker's role is to allow us to know how to call a client or a server.
//
const socket = Broker.getMessageBroker().getClient(clientId);
// Now we can call the client.
const appVersion = await socket!.callFunction<string>("getAppVersion");
// Returns all this information.
//
return {
yourId: clientId,
yourJwtToken: clientJwtToken,
yourUserInfos: userInfo,
yourAppVersion: appVersion
}
});
Here, three elements are particularly interesting:
-
const clientId = Broker.getCallingClientId();
which allows you to get the client's identifier, like their phone number. -
const socket = Broker.getMessageBroker().getClient(clientId);
which allows you to get a connection with this client. -
const appVersion = await socket!.callFunction<string>("getAppVersion");
which allows you to call a function located on the client.
Calling the function returnInfoAboutMe
The function we call getAppVersion
is an already existing function, part of the basic features.
So we don't need to create it. However, we do need to call Broker.setAppVersion to set the version number.
// >>> Define our app version.
Broker.setAppVersion("v1.0.0-beta");
Now we just need to add the code to call the returnInfoAboutMe
function we created.
// >>> Test: calling function "returnInfoAboutMe".
console.log("\nTest: calling 'returnInfoAboutMe'");
const infoAboutMe = await socket.callFunction("returnInfoAboutMe", "my-name");
console.log("--> Result:", infoAboutMe);
That's it. The following text should be displayed in the terminal where the client application is running:
Test: calling 'returnInfoAboutMe'
--> Result: {
yourId: "d9f38c9d-4f0f-4131-a0cc-9422fa3a4e96",
yourJwtToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbiI6ImltLXRoZS1ib3NzIiwibWFpbCI6ImltLXRoZS1ib3NzQG15LWNvbXBhbnkuY29tIiwicm9sZXMiOlsidXNlciIsInJlYWRlciJdLCJpYXQiOjE3NTI2ODcyNTcsImV4cCI6MTc1MzI5MjA1N30.3mLGbbg_GlO3Fudw__JztjHG8bReuFZNIXK-WSGgwUw",
yourUserInfos: {
login: "im-the-boss",
mail: "im-the-boss@my-company.com",
roles: [ "user", "reader" ],
iat: 1752687257,
exp: 1753292057,
},
yourAppVersion: "v1.0.0-beta",
}
Sending a file to our server
The ReadableStream object
One of the strengths of JopiRemoteCall is how easy it is to send a file from the client to the server (or vice versa). The logic behind file transfer is simple: when calling a function, as soon as the system detects a value of type ReadableStream
, this value is replaced by a special ReadableStream that transfers the data between the client and the server.
ReadableStream objects are part of the web standard. They exist in browsers, under Node.js, and with Bun.js. So this is not something specific to Jopi Remote Call.
Adding our function on the server side
We will start by creating the server function. It will be named uploadFile and will take two arguments: the file name and a ReadableStream. This ReadableStream will allow binary data to be transferred from the client application to our server.
// >>> Add the function uploadFile
Broker.registerFunction("uploadFile", async function(fileName: string, stream: ReadableStream): Promise<string> {
// Need to add this at the top of the file: import * as path from "node:path";
const filePath = path.resolve("./" + fileName);
// This function takes our stream and saves it to a file.
// With BunJs, this function does this internally: Bun.write(filePath, new Response(stream))
// With NodeJs it's way more complicated, and that's why we use this helper function which does all the stuff for us.
//
await ioSaveWebStreamToFile(stream, filePath);
return "File has been saved at: " + filePath;
});
As you can see, we are not doing anything special here. We just use standard functions to work with our stream. I have deliberately indicated what is behind the ioSaveWebStreamToFile
function to highlight that we are only using standard mechanisms.
Calling our function
// >>> Test: calling function "uploadFile".
console.log("\nTest: calling 'uploadFile'");
// You have to adapt these two lines to target a real file.
//
const myFileName = "image1.jpg";
const myStream = ioReadWebStreamFromFile(myFileName);
console.log("--> Result: ", await socket.callFunction("uploadFile", myFileName, myStream));
Again, we use a helper function to get a stream from a file.
More about streams
How are streams handled internally?
Internally, the data to be transferred is split into chunks of about 2Mb. Each chunk is transferred one by one in a synchronized way: the server requests the next chunk each time, then confirms its successful reception. This prevents saturating the network when sending large files.
A second benefit of this approach is packet loss: if the network fails, we restart the transfer from the last 2Mb, not the whole file. So it's a robust exchange system.
Transferring a file from a web form
The function ioWebStreamFromFormFile
allows you to get a ReadableStream from a File
object used in HTML forms. With this function, you can transfer the content of a web form containing files.
Limitation related to ReadableStream
When calling a function, the system checks each argument and determines if it is of type ReadableStream. This is a simple and fast method. The downside is that streams inside an array or object are not handled.
If you need to send multiple files, you can do this:
function myFunction(...streams: ReadableStream[])
but you cannot do this: function myFunction(streams: ReadableStream[])
(the difference